Skip to content

fix(automation): publish $error on the throw arm before deciding whether the failure routes - #16302

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-14955-error-refresh-asymmetry
Sep 6, 2026
Merged

fix(automation): publish $error on the throw arm before deciding whether the failure routes#16302
os-warren merged 2 commits into
mainfrom
claude/issue-14955-error-refresh-asymmetry

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #14955

Step 1 answered: there is no reason, so the asymmetry is the defect

The card set the criterion itself — if there is a reason, it is not recorded at the site and should be; if there is not, the asymmetry is the defect. Three independent readings say there is not:

  1. Archaeology. Both arms were born in one commit, cc8484224 ("enhance automation engine with fault edges, step logging, timeout, ..."), and the asymmetry was there at birth. The returned-failure arm was written with its own comment, // Write error output to variable context for downstream nodes, placed above the fault-edge lookup. The throw arm has only // Check for fault edges, with the write inside it. Two adjacent blocks in one bulk commit, written inconsistently — not a decision.
  2. Nothing affirmed it since. Every later commit through this region (Node-level error branches: let a flow handle a failed node instead of aborting the run #3863's guard-refusal class, [finding] A dying loop discards its body's completed steps wholesale — the run summary reports acted: 0 while 5 writes actually happened (childSteps splice only after a SUCCESSFUL node result) #13803 / try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry) #14184's step folds, automation: create_record collapses the engine's DUPLICATE_RECORD envelope to a string, so a flow's try_catch / fault edge still cannot tell "already there" from "the store is down" #14419's code) states in its own comment that it leaves $error semantics untouched. No ADR, no doc and no test records a rationale for the conditional; no test pins the absence of the write.
  3. The published contract already says the opposite. content/docs/automation/flows.mdx documents {$error} as naming "the most recent failure only". The conditional violated the contract that was already written down.

So this is the "asymmetry is the defect" branch of the card, and the fix is the one the claim comment named: make the throw arm's refresh symmetric with the returned-failure arm's. The throw shape is untouchedexecErr is rethrown exactly as caught, no exported symbol, signature or payload key moves. The wider alternative the card records (attach code to the thrown Error, have try_catch read it off the caught exception) is not taken here.

What changed

executeNode's throw arm now publishes $error and the node-scoped NODE_ID.error entry before deciding whether the failure routes, exactly as the returned-failure arm does. Publishing is not routing: the #3863 guard-refusal rule below the write is untouched and still decides, alone, which failures a fault edge may carry.

A thrown failure publishes { nodeId, message } and nothing more. There is no NodeExecutionResult on that path, so no output and no classified code exist to carry — and that absence is the correct answer for a throw, rather than a reason to leave a stale code standing.

The rationale is now recorded at the site, which was the other half of what the card asked for either way.

Why it mattered, in the card's own words

The message and the code come from two different failures.

A node inside a structured region never has a fault edge of its own — the region's synthetic sub-flow carries only the region's own edges (runRegion). So every thrown failure inside a try_catch, a loop body or any other region left $error naming an earlier, unrelated failure. The failure mode is not a crash but a plausible-looking wrong value: { code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }, swallowed by a catch region reading it as "the row is already there", with the run reporting success. That is exactly why it stayed invisible, and why the pins below drive flows to different edges rather than asserting a variable.

Pins — three flows, driven end to end

packages/services/service-automation/src/throw-arm-error-refresh.test.ts:

  1. A thrown failure with no fault edge of its own names itself on the run-wide $error. An earlier fault-routed create_record duplicate seeds a stale $error; a try_catch binding to $caught (deliberately not $error) then lets its catch region read the engine's own run-wide variable after a node inside the try region throws. Red before, green after.
  2. A swallowed failure's code does not leak onto a later thrown failure in the same try region. This is the residual the PR fix(automation): create_record surfaces the engine's DUPLICATE_RECORD code #14948 identity guard cannot see: that guard compares $error against what the attempt started with, so a rewrite happening inside the attempt window — here an inner try_catch binding its own swallowed duplicate — passes the identity check, and the outer container bound DUPLICATE_RECORD onto a timeout's message and swallowed it. The two outcomes take different edges (reraised then escalate, versus a silent swallow). Red before, green after.
  3. A thrown guard refusal is still un-routable. With a stale $error seeded first, so the new unconditional write is actually exercised on that path: the run still aborts with the guard's own message, the handler behind the fault edge never runs, and the destructive verb is never reached. Green before and after — a guard-rail, not a red-first pin, and stated as such.

On PR #14948 — not re-litigated

That PR's minimal identity guard and its two flow pins are left in place and unchanged. Its guard now has a narrower job rather than none: a throw that is not a node failure at all (a durable pause refused inside a region, a missing region entry — anything runRegion raises before reaching a node) still never touches $error, and the stale value must not be claimed then either. Two comments that described the old engine behaviour and became false with this change are corrected in place, without touching a single assertion.

Also unrelated to issue #15646 (same file, undispatched): nothing here touches runRegion or the pause path, and origin/main carried no packages/services/service-automation/ change between this branch's base and its merge of current main.

Verification — all on the final head 577a51e15

  • Ablation, direction predicted before running: putting both writes back inside if (faultEdge) should turn pins 1 and 2 red on the same two assertions and leave pin 3 green. Observed exactly that: 2 failed | 1 passed, expected 'a' to be 'boom' and expected 'DUPLICATE_RECORD' to be undefined. The mutation was confirmed on disk before the run (the 16-space spelling went 1 to 0, the 20-space spelling 0 to 1, and git hash-object differed from the HEAD blob), and the restore leg was confirmed the same way (hash identical to the HEAD blob, counts back to 1 and 0, git diff HEAD empty). No rebuild leg is owed: the pins import the subject as ./engine.js, a same-package relative specifier vitest resolves to src, not dist.
  • An earlier reading in the other direction, taken before the implementation existed: the same three pins on the unmodified tree gave 2 failed | 1 passed with those same two assertions.
  • pnpm --filter @objectstack/service-automation test — 120 files, 1422 tests, all passing.
  • pnpm --filter @objectstack/service-automation typecheck — clean, and check:test-typecheck reports the test layer compiling with 0 files / 0 errors in the ledger. Neither tsconfig in this package excludes *.test.ts, so the new pin file is inside both programs.
  • Gate family derived mechanically on this headnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, which read the change set from git itself (5 paths) and printed 56 commands. All 56 run, each exit code captured immediately after a single redirection with no pipe anywhere. 54 were green first time. Two returned exit 3, PREREQUISITE NOT MET, and were converted into real measurements rather than reported as passes: check:dual-build-cjs-loads needed the built closure (green after turbo run build over the packages), and check:type-check-debt needed the heap ceiling lint.yml declares for that step, --max-old-space-size=6144 (green at that ceiling; at 4096 its tsc re-measure died of heap exhaustion, which the gate correctly refuses to record as zero).
  • The four artifact-roster families the derivation flags as keeping their roster in a directory one of these paths is in — check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity — were run rather than read as silent. All green.
  • pnpm lint over the whole repository — green. No narrowing claimed and none needed.

A changeset is included: this changes runtime behaviour a flow author can observe, so it is not a skip-changeset shape.

🤖 Generated with Claude Code


Generated by Claude Code

…ether the failure routes

The engine's returned-failure arm rewrites the run-wide `$error` and
`<nodeId>.error` unconditionally, and only then asks whether a `fault` edge may
route the failure. The throw arm did both inside `if (faultEdge)`, so a thrown
failure with no fault edge of its own left `$error` naming an earlier, unrelated
failure — and a node inside a structured region never has a fault edge of its
own, because the region's synthetic sub-flow carries only the region's own edges.

The message and the code came from two different failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 3 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via DUPLICATE_RECORD (literal, a string literal in executeNode))
  • content/docs/automation/flows.mdx (via DUPLICATE_RECORD (literal, a string literal in executeNode))
  • content/docs/protocol/kernel/error-handling.mdx (via DUPLICATE_RECORD (literal, a string literal in executeNode))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7beaaa32ccb1295eeaec73bdca4aea36e713232dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1bf2875df31def2b0444f79bf99a26f9c95af87a — the merge of head 577a51e152bc333352443040c56495382c88892f into base 7beaaa32ccb1295eeaec73bdca4aea36e713232d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1bf2875df31def2b0444f79bf99a26f9c95af87a && git checkout 1bf2875df31def2b0444f79bf99a26f9c95af87a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7beaaa32ccb1295eeaec73bdca4aea36e713232d 577a51e152bc333352443040c56495382c88892f && git checkout -B drift-repro 7beaaa32ccb1295eeaec73bdca4aea36e713232d && git merge --no-ff 577a51e152bc333352443040c56495382c88892f

node scripts/docs-audit/affected-docs.mjs --json 7beaaa32ccb1295eeaec73bdca4aea36e713232d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7beaaa32ccb1295eeaec73bdca4aea36e713232d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

PM 复核 —— PASS

在 head 577a51e15、merge-base 2756e07d1 上独立实测。

边界守住了 —— 第 2 步没有被顺手做掉

派单把本卡拆成两步,只有第 1 步可做;第 2 步(改引擎的 throw 形状)必须停下拆卡送决策箱。我核了:engine.ts 的 diff 里唯一提到 throw / execErr 的行是一句注释 —— "Nor is the thrown value touched — execErr is …"没有任何 throw 语句被改动。 ⇒ 第 2 步没有进入,不欠拆卡。

⭐ 第 1 步的答案,以及它比「不对称」更硬的地方

卡片的判据是:若有理由,那个理由没写在现场,应当写上;若没有,那这个不对称本身就是缺陷。 席位答的是没有理由,三条读数。其中第 (c) 条我到源头核了,它才是真正的判据

content/docs/automation/flows.mdx:1253
| `{$error}` | run-wide | `{$error.nodeId}` / `{$error.message}` — the most recent failure only.

已发布的契约本来就写着「最近一次失败」。 那么条件式刷新违反的不是对称性偏好,而是一条我们对外发布的契约。这把本卡从「一个可能有人替它辩护的不对称」变成了「代码与我们公开声明的东西不一致」—— 后者不需要任何裁决。

另外两条也成立且互相加强:(a) 两条臂同一次批量提交 cc8484224 出生,返回失败臂在同一次提交里就是按另一种写法写的 ⇒ 当时没有一个「决定」可记录;(b) #3863 / #13803 / #14184 / #14419 各自在评论里声明不动 $error 语义,没有 ADR、文档或测试记录过理由。

⚠️ 一处我差点误判,记下来

我先按 runRegion|persistSuspendedRun|restoreConsumedSuspensionengine.ts 的 diff 上计数,得到 1 —— 与席位「没碰 runRegion / 暂停路径」的说法冲突。读行之后,那一处是:

+  // {@link AutomationEngine.runRegion}), so EVERY thrown failure

一句注释里的 {@link}。⇒ 席位的说法成立,#15646 无碰撞。⭐ 这正是本车道那条纪律的现场演示:grep 计数在任何方向都不能验证一个类 —— 要读行。

⚠️ 行号又漂了 ~470 行

分诊在 origin/main 上给的 :7411/:7414/:7507/:7510,席位在当前 head 实测为 :7884/:7887/:7980/:7983。而分诊那次已经是对卡面 :7088/:7167 的一次修正(漂 ~320 行)。⇒ 同一组符号在两天内漂了两次、累计 ~800 行。 符号在,行号不在。

⭐ Pin 2 是真正的收获:它是 #14948 的守卫看不见的残留

派单明确禁止重审 #14948。席位没有重审 —— 它做的是更有价值的事:pin 2 复现了卡片实测的那个形状 { code: 'DUPLICATE_RECORD', message: "Node 'slow' timed out after 20ms" },并指出这是 #14948 的身份守卫结构上看不到的:那个守卫比对的是本次尝试开始时$error,而一次发生在尝试窗口之内的重写(内层 try_catch 绑定了它自己吞掉的重复键)通过身份检查

#14948 的守卫不是冗余,但它也不充分。这一条是本卡的实测产出,不是对那条 PR 的再审。

⭐ 两处诚实,都值得记

  1. 预测失败,如实报出。 pin 3 第一版用 create_record 播种陈旧 $error,得到 ran=[] 而非 ['recoverA'] —— 同一条 ADR-0049 无作用域守卫(pin 本身要武装的那条)把播种也拒了,于是运行死在播种阶段,而前两条断言因为错误的原因通过了。⇒ 这是一个「不可能失败的测量」,席位自己发现并重写了播种。
  2. 测量工具本身出错,报出并重做。 消融的磁盘证明第一次用 grep -F 数缩进拼写,20 空格那行被当作 16 空格那行的子串命中,读数作废;改用 python 的精确整行相等重做,得到 16 空格 1→0、20 空格 0→1,加上 blob 哈希差异。

消融方向事先预测(pin 1、2 红在同样两条断言,pin 3 绿),实测完全符合。

验收

  • 新 pin 驱动到不同的边,不是断言变量值:实现前基线 2 failed | 1 passed(两条 AssertionError 与卡片实测形状一致),实现后 3 passed
  • 包内测试 120 文件 / 1422 通过,exit 0。typecheck exit 0,且核过覆盖(两个 tsconfig 都 include src、都不排除 *.test.ts)。
  • 门族在最终 head 机械推导 56 条全跑,54 条首次绿;两条 exit 3转成真实测量而非当作通过(check:dual-build-cjs-loads 补构建闭包;check:type-check-debtlint.yml 自己声明的 6144 堆上限 —— 4096 下 tsc 堆耗尽,而该门拒绝把它记成 0)。
  • NOT MEASURED 是被点名的,不是被暗示成绿的:6 个 argv 取值只有 CI 能提供的族、10 个宣告过宽无法定位的族、以及 always-runs 尾部。
  • 全仓 pnpm lint exit 0,未作任何窄化

契约面

throw 形状未动、无导出符号/签名/载荷键移动 ⇒ Clause-②: no 成立,无需更正。changeset 已判为 patch。

判定:PASS。needs:contract-review,⛔ 保持 draft、未 arm。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants